comparison libervia/web/pages/calls/_browser/webrtc.py @ 1564:bd3c880f4a47

browser (calls): add camera switching: - devices are listed on startup, and a `has_multiple_cameras` boolean is set - based on this data, switch camera button is hidden or not - a click/touch on the button will switch to the next available camera, and loop back to first one at the end
author Goffi <goffi@goffi.org>
date Fri, 18 Aug 2023 17:43:01 +0200
parents e3449beac8d8
children d282dbdd5ffd
comparison
equal deleted inserted replaced
1563:e3449beac8d8 1564:bd3c880f4a47
13 bridge = Bridge() 13 bridge = Bridge()
14 GATHER_TIMEOUT = 10000 14 GATHER_TIMEOUT = 10000
15 15
16 16
17 class WebRTC: 17 class WebRTC:
18 def __init__(self): 18 def __init__(
19 self,
20 screen_sharing_cb=None,
21 on_connection_established_cb=None,
22 on_reconnect_cb=None,
23 on_connection_lost_cb=None,
24 on_video_devices=None,
25 ):
19 self.reset_instance() 26 self.reset_instance()
27
28 # ICE events
20 bridge.register_signal("ice_candidates_new", self._on_ice_candidates_new) 29 bridge.register_signal("ice_candidates_new", self._on_ice_candidates_new)
21 bridge.register_signal("ice_restart", self._on_ice_restart) 30 bridge.register_signal("ice_restart", self._on_ice_restart)
22 self.on_connection_established_cb = None 31
23 self.on_reconnect_cb = None 32 # connection events callbacks
24 self.on_connection_lost_cb = None 33 self.on_connection_established_cb = on_connection_established_cb
34 self.on_reconnect_cb = on_reconnect_cb
35 self.on_connection_lost_cb = on_connection_lost_cb
36
37 # video devices
38 self.on_video_devices = on_video_devices
39 self.video_devices = []
40 self.has_multiple_cameras = False
41 self.current_camera = None
42
43 # Initially populate the video devices list
44 aio.run(self._populate_video_devices())
45
46 # muting
25 self.is_audio_muted = None 47 self.is_audio_muted = None
26 self.is_video_muted = None 48 self.is_video_muted = None
49
50 # screen sharing
27 self._is_sharing_screen = False 51 self._is_sharing_screen = False
28 self.screen_sharing_cb = None 52 self.screen_sharing_cb = screen_sharing_cb
53
54 # video elements
29 self.local_video_elt = document["local_video"] 55 self.local_video_elt = document["local_video"]
30 self.remote_video_elt = document["remote_video"] 56 self.remote_video_elt = document["remote_video"]
31 57
32 @property 58 @property
33 def is_sharing_screen(self) -> bool: 59 def is_sharing_screen(self) -> bool:
54 "video": {"candidates": []}, 80 "video": {"candidates": []},
55 } 81 }
56 self.media_candidates = {} 82 self.media_candidates = {}
57 self.candidates_gathered = aio.Future() 83 self.candidates_gathered = aio.Future()
58 84
85 async def _populate_video_devices(self):
86 devices = await window.navigator.mediaDevices.enumerateDevices()
87 devices_ids = set()
88 self.video_devices.clear()
89 for device in devices:
90 if device.kind != "videoinput":
91 continue
92 # we can have multiple devices with same IDs in some corner cases (e.g.
93 # infrared camera)
94 device_id = device.deviceId
95 if device_id in devices_ids:
96 continue
97 devices_ids.add(device_id)
98 self.video_devices.append(device)
99 self.has_multiple_cameras = len(self.video_devices) > 1
100 if self.on_video_devices is not None:
101 self.on_video_devices(self.has_multiple_cameras)
102 # Set the initial camera to the default (usually front on mobile)
103 if self.video_devices:
104 self.current_camera = self.video_devices[0].deviceId
105 log.debug(
106 f"devices populated: {self.video_devices=} {self.has_multiple_cameras=}"
107 )
108
59 @property 109 @property
60 def media_types(self): 110 def media_types(self):
61 if self._media_types is None: 111 if self._media_types is None:
62 raise Exception("self._media_types should not be None!") 112 raise Exception("self._media_types should not be None!")
63 return self._media_types 113 return self._media_types
316 else: 366 else:
317 if self.local_video_elt.srcObject: 367 if self.local_video_elt.srcObject:
318 for track in self.local_video_elt.srcObject.getTracks(): 368 for track in self.local_video_elt.srcObject.getTracks():
319 if track.kind == "video": 369 if track.kind == "video":
320 track.stop() 370 track.stop()
321 media_constraints = {"video": True} 371
372 media_constraints = {
373 "video": {"deviceId": self.current_camera}
374 if self.current_camera
375 else True
376 }
377
322 new_stream = await window.navigator.mediaDevices.getUserMedia( 378 new_stream = await window.navigator.mediaDevices.getUserMedia(
323 media_constraints 379 media_constraints
324 ) 380 )
325 381
326 if not new_stream: 382 if not new_stream:
366 new_video_tracks[0].bind("ended", on_track_ended) 422 new_video_tracks[0].bind("ended", on_track_ended)
367 423
368 self.is_sharing_screen = screen 424 self.is_sharing_screen = screen
369 425
370 return local_stream 426 return local_stream
427
428 async def switch_camera(self) -> None:
429 """Switches to the next available camera.
430
431 This method cycles through the list of available video devices, replaces the
432 current video track with the next one in the user's local video element, and then
433 updates the sender's track in the peer connection. If there's only one camera or
434 if an error occurs while switching, the method logs the error and does nothing.
435 """
436 log.info("switching camera")
437 if not self.has_multiple_cameras:
438 log.error("No multiple cameras to switch.")
439 return
440
441 current_camera_index = -1
442 for i, device_info in enumerate(self.video_devices):
443 if device_info.deviceId == self.current_camera:
444 current_camera_index = i
445 break
446
447 if current_camera_index == -1:
448 log.error("Current camera not found in available devices.")
449 return
450
451 # Switch to the next camera in the list
452 next_camera_index = (current_camera_index + 1) % len(self.video_devices)
453 self.current_camera = self.video_devices[next_camera_index].deviceId
454 log.debug(f"{next_camera_index=} {self.current_camera=}")
455
456 new_stream = await window.navigator.mediaDevices.getUserMedia(
457 {"video": {"deviceId": self.current_camera}}
458 )
459
460 new_video_tracks = [
461 track for track in new_stream.getTracks() if track.kind == "video"
462 ]
463
464 if not new_video_tracks:
465 log.error("Failed to retrieve the video track from the new stream.")
466 return
467
468 # Update local video element's stream
469 local_stream = self.local_video_elt.srcObject
470 if local_stream:
471 local_video_tracks = [
472 track for track in local_stream.getTracks() if track.kind == "video"
473 ]
474 if local_video_tracks:
475 local_video_tracks[0].stop()
476 local_stream.removeTrack(local_video_tracks[0])
477 local_stream.addTrack(new_video_tracks[0])
478 self.local_video_elt.srcObject = local_stream
479
480 # update remove video stream
481 video_sender = next(
482 (
483 sender
484 for sender in self._peer_connection.getSenders()
485 if sender.track and sender.track.kind == "video"
486 ),
487 None,
488 )
489
490 if video_sender:
491 await video_sender.replaceTrack(new_video_tracks[0])
492
371 493
372 async def _gather_ice_candidates(self, is_initiator: bool, remote_candidates=None): 494 async def _gather_ice_candidates(self, is_initiator: bool, remote_candidates=None):
373 """Get ICE candidates and wait to have them all before returning them 495 """Get ICE candidates and wait to have them all before returning them
374 496
375 @param is_initiator: Boolean indicating if the user is the initiator of the connection 497 @param is_initiator: Boolean indicating if the user is the initiator of the connection