Skip to main content

Change devices

This tutorial guides you through the process of changing the microphone and camera devices in your app. It covers essential steps such as detecting available media devices, requesting user permissions, and dynamically switching between different microphones and cameras using the MediaDevices API.

You can download the starter code from Step.04-Exercise-Change-devices.

Settings interface

We will start by defining an interface that we will use to store the current device selection. Create a new file in src/types/Settings.ts with the following content:

import {type MediaDeviceInfoLike} from '@pexip/media-control';

export interface Settings {
audioInput: MediaDeviceInfoLike | undefined;
audioOutput: MediaDeviceInfoLike | undefined;
videoInput: MediaDeviceInfoLike | undefined;
}

This interface will store the selected microphone (audioInput), camera (videoInput) and the speaker (audioOutput).

info

For changing the speaker we will use the sinkId, however it's not implemented in all browsers. For a compatibility list, you can check the MDN Web Docs.

LocalStorageKey interface

We will also define an enum to store the keys used to save the selected devices in the local storage.

Create a new file in src/types/LocalStorageKey.ts with the following content:

const prefix = 'pexip_';

export enum LocalStorageKey {
AudioInput = `${prefix}audio_input`,
AudioOutput = `${prefix}audio_output`,
VideoInput = `${prefix}video_input`,
}

App component

To implement this feature, we will start by editing the file src/App.tsx.

We start by importing the new necessary types:

import {type MediaDeviceInfoLike} from '@pexip/media-control';
import {type Settings} from './types/Settings';
import {LocalStorageKey} from './types/LocalStorageKey';

Add the following state variables to the App component:

const [devices, setDevices] = useState<MediaDeviceInfoLike[]>([]);

const [audioInput, setAudioInput] = useState<MediaDeviceInfoLike>();
const [audioOutput, setAudioOutput] = useState<MediaDeviceInfoLike>();
const [videoInput, setVideoInput] = useState<MediaDeviceInfoLike>();

These state variables will be used for the following purposes:

  • devices will store the list of available media devices (microphones, cameras and speakers).
  • audioInput, audioOutput, and videoInput will store the currently selected devices.

Now we will call the refreshDevices function (we will define it later) to get the available audioInput and videoInput devices. This function will take into account the devices stored in the local storage and also the devices currently available in the browser.

const handleStartConference = async (
nodeDomain: string,
conferenceAlias: string,
displayName: string
): Promise<void> => {
setNodeDomain(nodeDomain)
setConferenceAlias(conferenceAlias)
setDisplayName(displayName)
setConnectionState(ConnectionState.Connecting)

const { audioInput, videoInput } = await refreshDevices()
...
}

We will make a small change to the handleStartConference, handleAudioMute and handleVideoMute functions. Previously, we only requested audio ({ audio: true }) or video ({ video: true }) will pass a new constrain to getUserMedia with the deviceId of the selected device:

const localAudioStream = await navigator.mediaDevices.getUserMedia({
audio: {deviceId: audioInput?.deviceId},
});

const localVideoStream = await navigator.mediaDevices.getUserMedia({
video: {deviceId: videoInput?.deviceId},
});

Next, we will add a new function handleSettingsChange that will receive the new settings and update the audio and video streams accordingly.

This function will be responsible of:

  • Stopping the current audio and video streams.
  • Requesting the new audio and video streams.
  • Sending the new audio and video streams to Pexip Infinity.
  • Changing the speaker if the audio output has changed.

Here is the implementation of the handleSettingsChange function:

const handleSettingsChange = async (settings: Settings): Promise<void> => {
let newAudioStream: MediaStream | null = null;
let newVideoStream: MediaStream | null = null;

// Get the new audio stream if the audio input has changed
if (settings.audioInput !== audioInput) {
localAudioStream?.getTracks().forEach(track => {
track.stop();
});

setAudioInput(settings.audioInput);
localStorage.setItem(
LocalStorageKey.AudioInput,
JSON.stringify(settings.audioInput),
);

newAudioStream = await navigator.mediaDevices.getUserMedia({
audio: {deviceId: settings.audioInput?.deviceId},
});
setLocalAudioStream(newAudioStream);
}

// Get the new video stream if the video input has changed
if (settings.videoInput !== videoInput) {
localVideoStream?.getTracks().forEach(track => {
track.stop();
});

setVideoInput(settings.videoInput);
localStorage.setItem(
LocalStorageKey.VideoInput,
JSON.stringify(settings.videoInput),
);

newVideoStream = await navigator.mediaDevices.getUserMedia({
video: {deviceId: settings.videoInput?.deviceId},
});
setLocalVideoStream(newVideoStream);
}

// Send the new audio and video stream to Pexip Infinity
if (newAudioStream != null || newVideoStream != null) {
infinityClient.setStream(
new MediaStream([
...(newAudioStream?.getTracks() ?? localAudioStream?.getTracks() ?? []),
...(newVideoStream?.getTracks() ?? localVideoStream?.getTracks() ?? []),
]),
);
}

// Change the speaker if the audio output has changed
if (settings.audioOutput !== audioOutput) {
setAudioOutput(settings.audioOutput);
localStorage.setItem(
LocalStorageKey.AudioOutput,
JSON.stringify(settings.audioOutput),
);
}
};

Now is time to define the refreshDevices function that we used before. This function will request the list of available media devices and update the devices list and the selected devices:

const refreshDevices = async (): Promise<Settings> => {
const devices = await navigator.mediaDevices.enumerateDevices();
setDevices(devices);

const audioInput = getMediaDeviceInfo(devices, 'audioinput');
setAudioInput(audioInput);

const audioOutput = getMediaDeviceInfo(devices, 'audiooutput');
setAudioOutput(audioOutput);

const videoInput = getMediaDeviceInfo(devices, 'videoinput');
setVideoInput(videoInput);

return {
audioInput,
audioOutput,
videoInput,
effect,
};
};

We will make a small change to the useEffect hook. When the component is mounted, we will subscribe to the event devicechange which is triggered each time the user connects or disconnects a device. In case of a change, we will call the refreshDevices function to update the list of devices:

useEffect(() => {
...
const handleDeviceChange = (): void => {
refreshDevices().catch(console.error)
}

window.addEventListener('beforeunload', disconnectBrowserClosed)
window.addEventListener('devicechange', handleDeviceChange)
return () => {
window.removeEventListener('beforeunload', disconnectBrowserClosed)
window.removeEventListener('devicechange', handleDeviceChange)
}
}, [])

Finally, we will pass the devices, settings and onSettingsChange props to the Conference component:

<Conference
localVideoStream={localVideoStream}
remoteStream={remoteStream}
devices={devices}
settings={{
audioInput,
audioOutput,
videoInput
}}
onAudioMute={handleAudioMute}
onVideoMute={handleVideoMute}
onSettingsChange={handleSettingsChange}
onDisconnect={handleDisconnect}
/>

Conference component

Next, we will modify the src/components/Conference/Conference.tsx to include the new settings.

We start by importing all the libraries and types we will use:

import {useState} from 'react';
import {type MediaDeviceInfoLike} from '@pexip/media-control';
import {type Settings} from '../../types/Settings';
import {SettingsModal} from './SettingsModal/SettingsModal';

Add devices, settings and onSettingsChange to the ConferenceProps interface:

interface ConferenceProps {
localVideoStream: MediaStream | undefined;
remoteStream: MediaStream | undefined;
devices: MediaDeviceInfoLike[];
settings: Settings;
onAudioMute: (mute: boolean) => Promise<void>;
onVideoMute: (mute: boolean) => Promise<void>;
onSettingsChange: (settings: Settings) => Promise<void>;
onDisconnect: () => Promise<void>;
}

Inside the Conference component, we will add a new state variable settingsOpened to control the visibility of the settings modal:

const [settingsOpened, setSettingsOpened] = useState(false);

Add settingsOpened and onSettingsChange to the Toolbar component props:

<Toolbar
className="toolbar"
settingsOpened={settingsOpened}
onAudioMute={props.onAudioMute}
onVideoMute={props.onVideoMute}
onOpenSettings={() => {
setSettingsOpened(true)
}}
onDisconnect={props.onDisconnect}
/>

And we will create a instance of the SettingsModal component, just under the Toolbar that will display a modal dialog to change the devices:

<SettingsModal
isOpen={settingsOpened}
onClose={() => {
setSettingsOpened(false)
}}
devices={props.devices}
settings={props.settings}
onSettingsChange={props.onSettingsChange}
/>

Finally, we will define the getMediaDeviceInfo function that will return the device stored in the local storage if it is available, otherwise it will return the first device of the specified kind:

const getMediaDeviceInfo = (
devices: MediaDeviceInfoLike[],
kind: MediaDeviceKind,
): MediaDeviceInfoLike | undefined => {
let storageKey: string;

switch (kind) {
case 'audioinput': {
storageKey = LocalStorageKey.AudioInput;
break;
}
case 'audiooutput': {
storageKey = LocalStorageKey.AudioOutput;
break;
}
case 'videoinput': {
storageKey = LocalStorageKey.VideoInput;
break;
}
}

const mediaDeviceStored = localStorage.getItem(storageKey);

let mediaDeviceInfo: MediaDeviceInfoLike | undefined;

if (mediaDeviceStored != null) {
mediaDeviceInfo = JSON.parse(mediaDeviceStored) as MediaDeviceInfo;
const found = devices.some(
device => device.deviceId === mediaDeviceInfo?.deviceId,
);
if (!found) {
mediaDeviceInfo = devices.find(device => device.kind === kind);
}
} else {
mediaDeviceInfo = devices.find(device => device.kind === kind);
}
return mediaDeviceInfo;
};

Toolbar component

Now we will add a new button in the src/components/Conference/Toolbar/Toolbar.tsx file to open the settings modal.

Start by adding settingsOpened and onOpenSettings to the ToolbarProps interface:

interface ToolbarProps {
className: string;
settingsOpened: boolean;
onAudioMute: (mute: boolean) => Promise<void>;
onVideoMute: (mute: boolean) => Promise<void>;
onOpenSettings: () => void;
onDisconnect: () => Promise<void>;
}

And finally, add the new button to open the settings modal:

<Tooltip text={'Settings'}>
<Button
onClick={() => {
props.onOpenSettings()
}}
variant="translucent"
modifier="square"
isActive={!props.settingsOpened}
colorScheme="light"
>
<Icon source={IconTypes.IconSettings} />
</Button>
</Tooltip>

SettingsModal component

Finally, we will create a new component in the src/components/Conference/SettingsModal/SettingsModal.tsx file. This component will display a modal dialog to change the devices when the user clicks on the settings button in the toolbar.

We will start by defining the properties:

interface SettingsModalProps {
isOpen: boolean;
onClose: () => void;
devices: MediaDeviceInfoLike[];
settings: Settings;
onSettingsChange: (settings: Settings) => Promise<void>;
}

It receives the following properties:

  • isOpen: A boolean to control the visibility of the modal.
  • onClose: A function that will be called when the modal is closed.
  • devices: A list of available media devices.
  • settings: The current selected devices.
  • onSettingsChange: A function that will be called when the devices are changed.

And we will include this properties in the function signature:

export const SettingsModal = (props: SettingsModalProps): JSX.Element => {
...
}

Now we go inside the SettingsModal functional component and implement the logic to change the devices.

We will start by defining the state variables to store the local video stream and the selected devices:

const [localStream, setLocalStream] = useState<MediaStream>();

const [audioInput, setAudioInput] = useState<MediaDeviceInfoLike>();
const [audioOutput, setAudioOutput] = useState<MediaDeviceInfoLike>();
const [videoInput, setVideoInput] = useState<MediaDeviceInfoLike>();

We will create a function called handleVideoInputChange that will request the new video stream when the video input device is changed:

const handleVideoInputChange = (device: MediaDeviceInfoLike): void => {
if (device.deviceId !== videoInput?.deviceId) {
setVideoInput(device);
if (localStream != null) {
localStream.getVideoTracks().forEach(track => {
track.stop();
});
navigator.mediaDevices
.getUserMedia({
video: {deviceId: device.deviceId},
})
.then(stream => {
setLocalStream(stream);
})
.catch(console.error);
}
}
};

We will create a function handleSave that will save the new settings and close the modal:

const handleSave = (): void => {
props
.onSettingsChange({
audioInput,
audioOutput,
videoInput,
})
.catch(console.error);
props.onClose();
};

We will use the useEffect hook to request the local video stream when the modal is opened and stop the stream when the modal is closed:

useEffect(() => {
const bootstrap = async (): Promise<void> => {
if (props.isOpen) {
setAudioInput(props.settings.audioInput);
setAudioOutput(props.settings.audioOutput);
setVideoInput(props.settings.videoInput);

const localStream = await navigator.mediaDevices.getUserMedia({
video: {deviceId: props.settings.videoInput?.deviceId},
});
setLocalStream(localStream);
} else {
localStream?.getTracks().forEach(track => {
track.stop();
});
setLocalStream(undefined);
}
};
bootstrap().catch(console.error);
}, [props.isOpen]);

Finally, we render the SettingsModal component:

 return (
<Modal
isOpen={props.isOpen}
onClose={props.onClose}
className="SettingsModal"
withCloseButton={true}
>
<h3 className="Title">Settings</h3>

<Video srcObject={localStream} isMirrored={true} />

<div className="DeviceSelectionContainer">
<h4>Select devices</h4>

<DevicesSelection
devices={props.devices}
audioInput={audioInput}
audioOutput={audioOutput}
videoInput={videoInput}
onAudioInputChange={setAudioInput}
onAudioOutputChange={setAudioOutput}
onVideoInputChange={handleVideoInputChange}
setShowHelpVideo={() => {}}
videoInputError={{
title: '',
description: undefined,
deniedDevice: undefined
}}
audioInputError={{
title: '',
description: undefined,
deniedDevice: undefined
}}
/>
</div>

<div className="ButtonSet">
<Button variant="secondary" onClick={props.onClose}>
Cancel
</Button>
<Button onClick={handleSave}>Save</Button>
</div>
</Modal>
)

We rendered the following components:

  • Video component to display the local video stream.
  • DevicesSelection component to select the audio and video devices.
  • Button components to save or cancel the changes.

And that's it! You have successfully implemented the ability to change the microphone and camera devices in your app.

Run the app

When you run the app, you will see a new button in the interface. If you click on it, you will see a new modal dialog to select the camera, microphone and speaker. If you change the camera, you will see that the local video will be updated with the new camera.

Once the changes are saved, the new devices will be used in the conference.

You can compare your code with the solution in Step.04-Solution-Change devices. You can also check the differences with the previous lesson in the git diff.

Devices