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)