Mercurial > libervia-web
comparison libervia/web/pages/calls/_browser/webrtc.py @ 1553:83c2a6faa2ae
browser (calls): screen sharing implementation:
- the new screen sharing button toggle screen sharing state
- the button reflect the screen sharing state (green crossed when not sharing, red
uncrossed otherwise)
- the screen sharing stream replaces the camera one, and vice versa. No re-negociation is
needed.
- stopping the sharing through browser's dialog is supported
- the screen sharing button is only visibile if supported by the platform
rel 432
author | Goffi <goffi@goffi.org> |
---|---|
date | Mon, 14 Aug 2023 16:49:02 +0200 |
parents | e47c24204449 |
children | 410064b31dca |
comparison
equal
deleted
inserted
replaced
1552:c62027660ec1 | 1553:83c2a6faa2ae |
---|---|
1 import json | 1 import json |
2 import re | 2 import re |
3 | 3 |
4 from bridge import AsyncBridge as Bridge | 4 from bridge import AsyncBridge as Bridge |
5 from browser import aio, console as log, document, timer, window | 5 from browser import aio, console as log, document, timer, window |
6 import dialog | |
6 import errors | 7 import errors |
7 import jid | 8 from javascript import JSObject |
9 import jid | |
8 | 10 |
9 log.warning = log.warn | 11 log.warning = log.warn |
10 profile = window.profile or "" | 12 profile = window.profile or "" |
11 bridge = Bridge() | 13 bridge = Bridge() |
12 GATHER_TIMEOUT = 10000 | 14 GATHER_TIMEOUT = 10000 |
13 | 15 |
14 | 16 |
15 class WebRTC: | 17 class WebRTC: |
16 | |
17 def __init__(self): | 18 def __init__(self): |
18 self.reset_instance() | 19 self.reset_instance() |
19 bridge.register_signal("ice_candidates_new", self._on_ice_candidates_new) | 20 bridge.register_signal("ice_candidates_new", self._on_ice_candidates_new) |
20 self.is_audio_muted = None | 21 self.is_audio_muted = None |
21 self.is_video_muted = None | 22 self.is_video_muted = None |
23 self._is_sharing_screen = False | |
24 self.screen_sharing_cb = None | |
22 self.local_video_elt = document["local_video"] | 25 self.local_video_elt = document["local_video"] |
23 self.remote_video_elt = document["remote_video"] | 26 self.remote_video_elt = document["remote_video"] |
27 | |
28 @property | |
29 def is_sharing_screen(self) -> bool: | |
30 return self._is_sharing_screen | |
31 | |
32 @is_sharing_screen.setter | |
33 def is_sharing_screen(self, sharing: bool) -> None: | |
34 if sharing != self._is_sharing_screen: | |
35 self._is_sharing_screen = sharing | |
36 if self.screen_sharing_cb is not None: | |
37 self.screen_sharing_cb(sharing) | |
24 | 38 |
25 def reset_instance(self): | 39 def reset_instance(self): |
26 """Inits or resets the instance variables to their default state.""" | 40 """Inits or resets the instance variables to their default state.""" |
27 self._peer_connection = None | 41 self._peer_connection = None |
28 self._media_types = None | 42 self._media_types = None |
45 return self._media_types | 59 return self._media_types |
46 | 60 |
47 @media_types.setter | 61 @media_types.setter |
48 def media_types(self, new_media_types: dict) -> None: | 62 def media_types(self, new_media_types: dict) -> None: |
49 self._media_types = new_media_types | 63 self._media_types = new_media_types |
50 self._media_types_inv = {v:k for k,v in new_media_types.items()} | 64 self._media_types_inv = {v: k for k, v in new_media_types.items()} |
51 | 65 |
52 @property | 66 @property |
53 def media_types_inv(self) -> dict: | 67 def media_types_inv(self) -> dict: |
54 if self._media_types_inv is None: | 68 if self._media_types_inv is None: |
55 raise Exception("self._media_types_inv should not be None!") | 69 raise Exception("self._media_types_inv should not be None!") |
86 @return: A dictionary containing the fingerprint data. | 100 @return: A dictionary containing the fingerprint data. |
87 """ | 101 """ |
88 fingerprint_line = re.search(r"a=fingerprint:(\S+)\s+(\S+)", sdp) | 102 fingerprint_line = re.search(r"a=fingerprint:(\S+)\s+(\S+)", sdp) |
89 if fingerprint_line: | 103 if fingerprint_line: |
90 algorithm, fingerprint = fingerprint_line.groups() | 104 algorithm, fingerprint = fingerprint_line.groups() |
91 fingerprint_data = { | 105 fingerprint_data = {"hash": algorithm, "fingerprint": fingerprint} |
92 "hash": algorithm, | |
93 "fingerprint": fingerprint | |
94 } | |
95 | 106 |
96 setup_line = re.search(r"a=setup:(\S+)", sdp) | 107 setup_line = re.search(r"a=setup:(\S+)", sdp) |
97 if setup_line: | 108 if setup_line: |
98 setup = setup_line.group(1) | 109 setup = setup_line.group(1) |
99 fingerprint_data["setup"] = setup | 110 fingerprint_data["setup"] = setup |
142 base_format = ( | 153 base_format = ( |
143 "candidate:{foundation} {component_id} {transport} {priority} " | 154 "candidate:{foundation} {component_id} {transport} {priority} " |
144 "{address} {port} typ {type}" | 155 "{address} {port} typ {type}" |
145 ) | 156 ) |
146 | 157 |
147 if ((parsed_candidate.get('rel_addr') | 158 if parsed_candidate.get("rel_addr") and parsed_candidate.get("rel_port"): |
148 and parsed_candidate.get('rel_port'))): | |
149 base_format += " raddr {rel_addr} rport {rel_port}" | 159 base_format += " raddr {rel_addr} rport {rel_port}" |
150 | 160 |
151 if parsed_candidate.get('generation'): | 161 if parsed_candidate.get("generation"): |
152 base_format += " generation {generation}" | 162 base_format += " generation {generation}" |
153 | 163 |
154 return base_format.format(**parsed_candidate) | 164 return base_format.format(**parsed_candidate) |
155 | 165 |
156 def on_ice_candidate(self, event): | 166 def on_ice_candidate(self, event): |
185 media_types = {} | 195 media_types = {} |
186 mline_index = 0 | 196 mline_index = 0 |
187 | 197 |
188 for line in sdp_lines: | 198 for line in sdp_lines: |
189 if line.startswith("m="): | 199 if line.startswith("m="): |
190 media_types[mline_index] = line[2:line.find(" ")] | 200 media_types[mline_index] = line[2 : line.find(" ")] |
191 mline_index += 1 | 201 mline_index += 1 |
192 | 202 |
193 self.media_types = media_types | 203 self.media_types = media_types |
194 | 204 |
195 def on_ice_gathering_state_change(self, event): | 205 def on_ice_gathering_state_change(self, event): |
216 for server in external_disco: | 226 for server in external_disco: |
217 ice_server = {} | 227 ice_server = {} |
218 if server["type"] == "stun": | 228 if server["type"] == "stun": |
219 ice_server["urls"] = f"stun:{server['host']}:{server['port']}" | 229 ice_server["urls"] = f"stun:{server['host']}:{server['port']}" |
220 elif server["type"] == "turn": | 230 elif server["type"] == "turn": |
221 ice_server["urls"] = ( | 231 ice_server[ |
222 f"turn:{server['host']}:{server['port']}?transport={server['transport']}" | 232 "urls" |
223 ) | 233 ] = f"turn:{server['host']}:{server['port']}?transport={server['transport']}" |
224 ice_server["username"] = server["username"] | 234 ice_server["username"] = server["username"] |
225 ice_server["credential"] = server["password"] | 235 ice_server["credential"] = server["password"] |
226 ice_servers.append(ice_server) | 236 ice_servers.append(ice_server) |
227 | 237 |
228 rtc_configuration = {"iceServers": ice_servers} | 238 rtc_configuration = {"iceServers": ice_servers} |
229 | 239 |
230 peer_connection = window.RTCPeerConnection.new(rtc_configuration) | 240 peer_connection = window.RTCPeerConnection.new(rtc_configuration) |
231 peer_connection.addEventListener("track", self.on_track) | 241 peer_connection.addEventListener("track", self.on_track) |
232 peer_connection.addEventListener("negotiationneeded", self.on_negotiation_needed) | 242 peer_connection.addEventListener("negotiationneeded", self.on_negotiation_needed) |
233 peer_connection.addEventListener("icecandidate", self.on_ice_candidate) | 243 peer_connection.addEventListener("icecandidate", self.on_ice_candidate) |
234 peer_connection.addEventListener("icegatheringstatechange", self.on_ice_gathering_state_change) | 244 peer_connection.addEventListener( |
245 "icegatheringstatechange", self.on_ice_gathering_state_change | |
246 ) | |
235 | 247 |
236 self._peer_connection = peer_connection | 248 self._peer_connection = peer_connection |
237 window.pc = self._peer_connection | 249 window.pc = self._peer_connection |
238 | 250 |
239 async def _get_user_media( | 251 async def _get_user_media(self, audio: bool = True, video: bool = True) -> None: |
240 self, | 252 """ |
241 audio: bool = True, | 253 Gets user media (camera and microphone). |
242 video: bool = True | 254 |
243 ): | 255 @param audio: True if an audio flux is required. |
244 """Gets user media | 256 @param video: True if a video flux is required. |
245 | 257 """ |
246 @param audio: True if an audio flux is required | 258 media_constraints = {"audio": audio, "video": video} |
247 @param video: True if a video flux is required | |
248 """ | |
249 media_constraints = {'audio': audio, 'video': video} | |
250 local_stream = await window.navigator.mediaDevices.getUserMedia(media_constraints) | 259 local_stream = await window.navigator.mediaDevices.getUserMedia(media_constraints) |
260 | |
261 if not local_stream: | |
262 log.error("Failed to get the media stream.") | |
263 return | |
264 | |
251 self.local_video_elt.srcObject = local_stream | 265 self.local_video_elt.srcObject = local_stream |
252 | 266 |
253 for track in local_stream.getTracks(): | 267 for track in local_stream.getTracks(): |
254 self._peer_connection.addTrack(track) | 268 self._peer_connection.addTrack(track) |
255 | 269 |
270 async def _replace_user_video( | |
271 self, | |
272 screen: bool = False, | |
273 ) -> JSObject | None: | |
274 """Replaces the user video track with either a camera or desktop sharing track. | |
275 | |
276 @param screen: True if desktop sharing is required. False will use the camera. | |
277 @return: The local media stream or None if failed. | |
278 """ | |
279 if screen: | |
280 media_constraints = {"video": {"cursor": "always"}} | |
281 new_stream = await window.navigator.mediaDevices.getDisplayMedia( | |
282 media_constraints | |
283 ) | |
284 else: | |
285 if self.local_video_elt.srcObject: | |
286 for track in self.local_video_elt.srcObject.getTracks(): | |
287 if track.kind == "video": | |
288 track.stop() | |
289 media_constraints = {"video": True} | |
290 new_stream = await window.navigator.mediaDevices.getUserMedia( | |
291 media_constraints | |
292 ) | |
293 | |
294 if not new_stream: | |
295 log.error("Failed to get the media stream.") | |
296 return None | |
297 | |
298 new_video_tracks = [ | |
299 track for track in new_stream.getTracks() if track.kind == "video" | |
300 ] | |
301 | |
302 if not new_video_tracks: | |
303 log.error("Failed to retrieve the video track from the new stream.") | |
304 return None | |
305 | |
306 # Retrieve the current local stream's video track. | |
307 local_stream = self.local_video_elt.srcObject | |
308 if local_stream: | |
309 local_video_tracks = [ | |
310 track for track in local_stream.getTracks() if track.kind == "video" | |
311 ] | |
312 if local_video_tracks: | |
313 # Remove the old video track and add the new one to the local stream. | |
314 local_stream.removeTrack(local_video_tracks[0]) | |
315 local_stream.addTrack(new_video_tracks[0]) | |
316 | |
317 video_sender = next( | |
318 ( | |
319 sender | |
320 for sender in self._peer_connection.getSenders() | |
321 if sender.track and sender.track.kind == "video" | |
322 ), | |
323 None, | |
324 ) | |
325 if video_sender: | |
326 await video_sender.replaceTrack(new_video_tracks[0]) | |
327 | |
328 if screen: | |
329 # For screen sharing, we track the end event to properly stop the sharing when | |
330 # the user clicks on the browser's stop sharing dialog. | |
331 def on_track_ended(event): | |
332 aio.run(self.toggle_screen_sharing()) | |
333 | |
334 new_video_tracks[0].bind("ended", on_track_ended) | |
335 | |
336 self.is_sharing_screen = screen | |
337 | |
338 return local_stream | |
339 | |
256 async def _gather_ice_candidates(self, is_initiator: bool, remote_candidates=None): | 340 async def _gather_ice_candidates(self, is_initiator: bool, remote_candidates=None): |
257 """Get ICE candidates and wait to have them all before returning them | 341 """Get ICE candidates and wait to have them all before returning them |
258 | 342 |
259 @param is_initiator: Boolean indicating if the user is the initiator of the connection | 343 @param is_initiator: Boolean indicating if the user is the initiator of the connection |
260 @param remote_candidates: Remote ICE candidates, if any | 344 @param remote_candidates: Remote ICE candidates, if any |
261 """ | 345 """ |
262 if self._peer_connection is None: | 346 if self._peer_connection is None: |
263 raise Exception("The peer connection must be created before gathering ICE candidates!") | 347 raise Exception( |
348 "The peer connection must be created before gathering ICE candidates!" | |
349 ) | |
264 | 350 |
265 self.media_candidates.clear() | 351 self.media_candidates.clear() |
266 gather_timeout = timer.set_timeout( | 352 gather_timeout = timer.set_timeout( |
267 lambda: self.candidates_gathered.set_exception( | 353 lambda: self.candidates_gathered.set_exception( |
268 errors.TimeoutError("ICE gathering time out") | 354 errors.TimeoutError("ICE gathering time out") |
269 ), | 355 ), |
270 GATHER_TIMEOUT | 356 GATHER_TIMEOUT, |
271 ) | 357 ) |
272 | 358 |
273 if is_initiator: | 359 if is_initiator: |
274 offer = await self._peer_connection.createOffer() | 360 offer = await self._peer_connection.createOffer() |
275 self._set_media_types(offer) | 361 self._set_media_types(offer) |
296 | 382 |
297 @param session_id: Session identifier | 383 @param session_id: Session identifier |
298 @param sdp: Session Description Protocol data | 384 @param sdp: Session Description Protocol data |
299 @param profile: Profile associated | 385 @param profile: Profile associated |
300 """ | 386 """ |
301 await self._peer_connection.setRemoteDescription({ | 387 await self._peer_connection.setRemoteDescription({"type": "answer", "sdp": sdp}) |
302 "type": "answer", | |
303 "sdp": sdp | |
304 }) | |
305 await self.on_ice_candidates_new(self.candidates_buffer) | 388 await self.on_ice_candidates_new(self.candidates_buffer) |
306 self.candidates_buffer.clear() | 389 self.candidates_buffer.clear() |
307 | 390 |
308 def _on_ice_candidates_new(self, sid: str, candidates_s: str, profile: str) -> None: | 391 def _on_ice_candidates_new(self, sid: str, candidates_s: str, profile: str) -> None: |
309 """Called when new ICE candidates are received | 392 """Called when new ICE candidates are received |
311 @param sid: Session identifier | 394 @param sid: Session identifier |
312 @param candidates_s: ICE candidates serialized | 395 @param candidates_s: ICE candidates serialized |
313 @param profile: Profile associated with the action | 396 @param profile: Profile associated with the action |
314 """ | 397 """ |
315 if sid != self.sid: | 398 if sid != self.sid: |
316 log.debug( | 399 log.debug(f"ignoring peer ice candidates for {sid=} ({self.sid=}).") |
317 f"ignoring peer ice candidates for {sid=} ({self.sid=})." | |
318 ) | |
319 return | 400 return |
320 candidates = json.loads(candidates_s) | 401 candidates = json.loads(candidates_s) |
321 aio.run(self.on_ice_candidates_new(candidates)) | 402 aio.run(self.on_ice_candidates_new(candidates)) |
322 | 403 |
323 async def on_ice_candidates_new(self, candidates: dict) -> None: | 404 async def on_ice_candidates_new(self, candidates: dict) -> None: |
342 try: | 423 try: |
343 sdp_mline_index = self.get_sdp_mline_index(media_type) | 424 sdp_mline_index = self.get_sdp_mline_index(media_type) |
344 except Exception as e: | 425 except Exception as e: |
345 log.warning(e) | 426 log.warning(e) |
346 continue | 427 continue |
347 ice_candidate = window.RTCIceCandidate.new({ | 428 ice_candidate = window.RTCIceCandidate.new( |
348 "candidate": candidate_sdp, | 429 {"candidate": candidate_sdp, "sdpMLineIndex": sdp_mline_index} |
349 "sdpMLineIndex": sdp_mline_index | 430 ) |
350 } | |
351 ) | |
352 await self._peer_connection.addIceCandidate(ice_candidate) | 431 await self._peer_connection.addIceCandidate(ice_candidate) |
353 | 432 |
354 def on_track(self, event): | 433 def on_track(self, event): |
355 """New track has been received from peer | 434 """New track has been received from peer |
356 | 435 |
371 | 450 |
372 async def answer_call(self, sid: str, offer_sdp: str, profile: str): | 451 async def answer_call(self, sid: str, offer_sdp: str, profile: str): |
373 """We respond to the call""" | 452 """We respond to the call""" |
374 log.debug("answering call") | 453 log.debug("answering call") |
375 if sid != self.sid: | 454 if sid != self.sid: |
376 raise Exception( | 455 raise Exception(f"Internal Error: unexpected sid: {sid=} {self.sid=}") |
377 f"Internal Error: unexpected sid: {sid=} {self.sid=}" | |
378 ) | |
379 await self._create_peer_connection() | 456 await self._create_peer_connection() |
380 | 457 |
381 await self._peer_connection.setRemoteDescription({ | 458 await self._peer_connection.setRemoteDescription( |
382 "type": "offer", | 459 {"type": "offer", "sdp": offer_sdp} |
383 "sdp": offer_sdp | 460 ) |
384 }) | |
385 await self.on_ice_candidates_new(self.candidates_buffer) | 461 await self.on_ice_candidates_new(self.candidates_buffer) |
386 self.candidates_buffer.clear() | 462 self.candidates_buffer.clear() |
387 await self._get_user_media() | 463 await self._get_user_media() |
388 | 464 |
389 # Gather local ICE candidates | 465 # Gather local ICE candidates |
391 self.local_candidates = local_ice_data["candidates"] | 467 self.local_candidates = local_ice_data["candidates"] |
392 | 468 |
393 await bridge.call_answer_sdp(sid, self._peer_connection.localDescription.sdp) | 469 await bridge.call_answer_sdp(sid, self._peer_connection.localDescription.sdp) |
394 | 470 |
395 async def make_call( | 471 async def make_call( |
396 self, | 472 self, callee_jid: jid.JID, audio: bool = True, video: bool = True |
397 callee_jid: jid.JID, | |
398 audio: bool = True, | |
399 video: bool = True | |
400 ) -> None: | 473 ) -> None: |
401 """Start a WebRTC call | 474 """Start a WebRTC call |
402 | 475 |
403 @param audio: True if an audio flux is required | 476 @param audio: True if an audio flux is required |
404 @param video: True if a video flux is required | 477 @param video: True if a video flux is required |
405 """ | 478 """ |
406 await self._create_peer_connection() | 479 await self._create_peer_connection() |
407 await self._get_user_media(audio, video) | 480 await self._get_user_media(audio, video) |
408 await self._gather_ice_candidates(True) | 481 await self._gather_ice_candidates(True) |
409 | 482 |
410 call_data = { | 483 call_data = {"sdp": self._peer_connection.localDescription.sdp} |
411 "sdp": self._peer_connection.localDescription.sdp | |
412 } | |
413 log.info(f"calling {callee_jid!r}") | 484 log.info(f"calling {callee_jid!r}") |
414 self.sid = await bridge.call_start( | 485 self.sid = await bridge.call_start(str(callee_jid), json.dumps(call_data)) |
415 str(callee_jid), | |
416 json.dumps(call_data) | |
417 ) | |
418 log.debug(f"Call SID: {self.sid}") | 486 log.debug(f"Call SID: {self.sid}") |
419 | 487 |
420 async def end_call(self) -> None: | 488 async def end_call(self) -> None: |
421 """Stop streaming and clean instance""" | 489 """Stop streaming and clean instance""" |
422 if self._peer_connection is None: | 490 if self._peer_connection is None: |
423 log.debug("There is currently no call to end.") | 491 log.debug("There is currently no call to end.") |
424 else: | 492 else: |
425 self._peer_connection.removeEventListener("track", self.on_track) | 493 self._peer_connection.removeEventListener("track", self.on_track) |
426 self._peer_connection.removeEventListener("negotiationneeded", self.on_negotiation_needed) | 494 self._peer_connection.removeEventListener( |
427 self._peer_connection.removeEventListener("icecandidate", self.on_ice_candidate) | 495 "negotiationneeded", self.on_negotiation_needed |
428 self._peer_connection.removeEventListener("icegatheringstatechange", self.on_ice_gathering_state_change) | 496 ) |
497 self._peer_connection.removeEventListener( | |
498 "icecandidate", self.on_ice_candidate | |
499 ) | |
500 self._peer_connection.removeEventListener( | |
501 "icegatheringstatechange", self.on_ice_gathering_state_change | |
502 ) | |
429 | 503 |
430 # Base64 encoded 1x1 black pixel image | 504 # Base64 encoded 1x1 black pixel image |
431 # this is a trick to reset the image displayed, so we don't see last image of | 505 # this is a trick to reset the image displayed, so we don't see last image of |
432 # last stream | 506 # last stream |
433 black_image_data = ( | 507 black_image_data = ( |
460 | 534 |
461 local_video = self.local_video_elt | 535 local_video = self.local_video_elt |
462 is_muted_attr = f"is_{media_type}_muted" | 536 is_muted_attr = f"is_{media_type}_muted" |
463 | 537 |
464 if local_video.srcObject: | 538 if local_video.srcObject: |
465 track_getter = getattr(local_video.srcObject, f"get{media_type.capitalize()}Tracks") | 539 log.debug(f"{local_video.srcObject=}") |
540 track_getter = getattr( | |
541 local_video.srcObject, f"get{media_type.capitalize()}Tracks" | |
542 ) | |
543 log.debug("track go") | |
466 for track in track_getter(): | 544 for track in track_getter(): |
545 log.debug(f"{track=}") | |
467 track.enabled = not track.enabled | 546 track.enabled = not track.enabled |
468 setattr(self, is_muted_attr, not track.enabled) | 547 setattr(self, is_muted_attr, not track.enabled) |
469 | 548 |
470 media_name = self.media_types_inv.get(media_type) | 549 media_name = self.media_types_inv.get(media_type) |
471 if media_name is not None: | 550 if media_name is not None: |
485 return self.toggle_media_mute("audio") | 564 return self.toggle_media_mute("audio") |
486 | 565 |
487 def toggle_video_mute(self) -> bool: | 566 def toggle_video_mute(self) -> bool: |
488 """Toggle mute/unmute for video tracks.""" | 567 """Toggle mute/unmute for video tracks.""" |
489 return self.toggle_media_mute("video") | 568 return self.toggle_media_mute("video") |
569 | |
570 async def toggle_screen_sharing(self): | |
571 log.debug(f"toggle_screen_sharing {self._is_sharing_screen=}") | |
572 | |
573 if self._is_sharing_screen: | |
574 await self._replace_user_video(screen=False) | |
575 else: | |
576 await self._replace_user_video(screen=True) |