# HG changeset patch # User Goffi # Date 1692373381 -7200 # Node ID bd3c880f4a4713dd4266b07d37cc9e032d9e5896 # Parent e3449beac8d86e49c3500e2ec929e5f55dcdcba4 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 diff -r e3449beac8d8 -r bd3c880f4a47 libervia/web/pages/calls/_browser/__init__.py --- a/libervia/web/pages/calls/_browser/__init__.py Thu Aug 17 16:23:01 2023 +0200 +++ b/libervia/web/pages/calls/_browser/__init__.py Fri Aug 18 17:43:01 2023 +0200 @@ -34,11 +34,13 @@ class CallUI: def __init__(self): - self.webrtc = WebRTC() - self.webrtc.screen_sharing_cb = self.on_sharing_screen - self.webrtc.on_connection_established_cb = self.on_connection_established - self.webrtc.on_reconnect_cb = self.on_reconnect - self.webrtc.on_connection_lost_cb = self.on_connection_lost + self.webrtc = WebRTC( + screen_sharing_cb=self.on_sharing_screen, + on_connection_established_cb=self.on_connection_established, + on_reconnect_cb=self.on_reconnect, + on_connection_lost_cb=self.on_connection_lost, + on_video_devices=self.on_video_devices, + ) self.mode = "search" self._status = None self._callee = None @@ -71,13 +73,14 @@ ) document["mute_audio_btn"].bind("click", self.toggle_audio_mute) document["mute_video_btn"].bind("click", self.toggle_video_mute) - self.share_desktop_btn_elt = document["share_desktop_btn"] + self.share_desktop_col_elt = document["share_desktop_column"] if hasattr(window.navigator.mediaDevices, "getDisplayMedia"): - self.share_desktop_btn_elt.classList.remove("is-hidden-touch") + self.share_desktop_col_elt.classList.remove("is-hidden-touch") # screen sharing is supported - self.share_desktop_btn_elt.bind("click", self.toggle_screen_sharing) + document["share_desktop_btn"].bind("click", self.toggle_screen_sharing) else: - self.share_desktop_btn_elt.classList.add("is-hidden") + self.share_desktop_col_elt.classList.add("is-hidden") + document["switch_camera_btn"].bind("click", self.on_switch_camera) # search self.search_elt = document["search"] @@ -309,6 +312,13 @@ def on_connection_lost(self): self.status = "connection-lost" + def on_video_devices(self, has_multiple_cameras: bool) -> None: + switch_camera_col_elt = document["switch_camera_column"] + if has_multiple_cameras: + switch_camera_col_elt.classList.remove("is-hidden", "is-hidden-desktop") + else: + switch_camera_col_elt.classList.add("is-hidden") + async def make_call(self, audio: bool = True, video: bool = True) -> None: """Start a WebRTC call @@ -444,7 +454,7 @@ if not self.search_elt.value: return # clear the search field - self.search_elt.value = '' + self.search_elt.value = "" # and dispatch the input event so items are updated self.search_elt.dispatchEvent(window.Event.new("input")) @@ -510,7 +520,7 @@ def on_sharing_screen(self, sharing: bool) -> None: """Called when screen sharing state changes""" - share_desktop_btn_elt = self.share_desktop_btn_elt + share_desktop_btn_elt = document["share_desktop_btn"] if sharing: share_desktop_btn_elt.classList.add("is-danger") share_desktop_btn_elt.classList.remove(INACTIVE_CLASS, SCREEN_OFF_CLASS) @@ -518,6 +528,9 @@ share_desktop_btn_elt.classList.remove("is-danger") share_desktop_btn_elt.classList.add(INACTIVE_CLASS, SCREEN_OFF_CLASS) + def on_switch_camera(self, __) -> None: + aio.run(self.webrtc.switch_camera()) + def _on_entity_click(self, item: dict) -> None: aio.run(self.on_entity_click(item)) diff -r e3449beac8d8 -r bd3c880f4a47 libervia/web/pages/calls/_browser/webrtc.py --- a/libervia/web/pages/calls/_browser/webrtc.py Thu Aug 17 16:23:01 2023 +0200 +++ b/libervia/web/pages/calls/_browser/webrtc.py Fri Aug 18 17:43:01 2023 +0200 @@ -15,17 +15,43 @@ class WebRTC: - def __init__(self): + def __init__( + self, + screen_sharing_cb=None, + on_connection_established_cb=None, + on_reconnect_cb=None, + on_connection_lost_cb=None, + on_video_devices=None, + ): self.reset_instance() + + # ICE events bridge.register_signal("ice_candidates_new", self._on_ice_candidates_new) bridge.register_signal("ice_restart", self._on_ice_restart) - self.on_connection_established_cb = None - self.on_reconnect_cb = None - self.on_connection_lost_cb = None + + # connection events callbacks + self.on_connection_established_cb = on_connection_established_cb + self.on_reconnect_cb = on_reconnect_cb + self.on_connection_lost_cb = on_connection_lost_cb + + # video devices + self.on_video_devices = on_video_devices + self.video_devices = [] + self.has_multiple_cameras = False + self.current_camera = None + + # Initially populate the video devices list + aio.run(self._populate_video_devices()) + + # muting self.is_audio_muted = None self.is_video_muted = None + + # screen sharing self._is_sharing_screen = False - self.screen_sharing_cb = None + self.screen_sharing_cb = screen_sharing_cb + + # video elements self.local_video_elt = document["local_video"] self.remote_video_elt = document["remote_video"] @@ -56,6 +82,30 @@ self.media_candidates = {} self.candidates_gathered = aio.Future() + async def _populate_video_devices(self): + devices = await window.navigator.mediaDevices.enumerateDevices() + devices_ids = set() + self.video_devices.clear() + for device in devices: + if device.kind != "videoinput": + continue + # we can have multiple devices with same IDs in some corner cases (e.g. + # infrared camera) + device_id = device.deviceId + if device_id in devices_ids: + continue + devices_ids.add(device_id) + self.video_devices.append(device) + self.has_multiple_cameras = len(self.video_devices) > 1 + if self.on_video_devices is not None: + self.on_video_devices(self.has_multiple_cameras) + # Set the initial camera to the default (usually front on mobile) + if self.video_devices: + self.current_camera = self.video_devices[0].deviceId + log.debug( + f"devices populated: {self.video_devices=} {self.has_multiple_cameras=}" + ) + @property def media_types(self): if self._media_types is None: @@ -318,7 +368,13 @@ for track in self.local_video_elt.srcObject.getTracks(): if track.kind == "video": track.stop() - media_constraints = {"video": True} + + media_constraints = { + "video": {"deviceId": self.current_camera} + if self.current_camera + else True + } + new_stream = await window.navigator.mediaDevices.getUserMedia( media_constraints ) @@ -369,6 +425,72 @@ return local_stream + async def switch_camera(self) -> None: + """Switches to the next available camera. + + This method cycles through the list of available video devices, replaces the + current video track with the next one in the user's local video element, and then + updates the sender's track in the peer connection. If there's only one camera or + if an error occurs while switching, the method logs the error and does nothing. + """ + log.info("switching camera") + if not self.has_multiple_cameras: + log.error("No multiple cameras to switch.") + return + + current_camera_index = -1 + for i, device_info in enumerate(self.video_devices): + if device_info.deviceId == self.current_camera: + current_camera_index = i + break + + if current_camera_index == -1: + log.error("Current camera not found in available devices.") + return + + # Switch to the next camera in the list + next_camera_index = (current_camera_index + 1) % len(self.video_devices) + self.current_camera = self.video_devices[next_camera_index].deviceId + log.debug(f"{next_camera_index=} {self.current_camera=}") + + new_stream = await window.navigator.mediaDevices.getUserMedia( + {"video": {"deviceId": self.current_camera}} + ) + + new_video_tracks = [ + track for track in new_stream.getTracks() if track.kind == "video" + ] + + if not new_video_tracks: + log.error("Failed to retrieve the video track from the new stream.") + return + + # Update local video element's stream + local_stream = self.local_video_elt.srcObject + if local_stream: + local_video_tracks = [ + track for track in local_stream.getTracks() if track.kind == "video" + ] + if local_video_tracks: + local_video_tracks[0].stop() + local_stream.removeTrack(local_video_tracks[0]) + local_stream.addTrack(new_video_tracks[0]) + self.local_video_elt.srcObject = local_stream + + # update remove video stream + video_sender = next( + ( + sender + for sender in self._peer_connection.getSenders() + if sender.track and sender.track.kind == "video" + ), + None, + ) + + if video_sender: + await video_sender.replaceTrack(new_video_tracks[0]) + + async def _gather_ice_candidates(self, is_initiator: bool, remote_candidates=None): """Get ICE candidates and wait to have them all before returning them